Building Semantic Segmentation using satellite imagery

The XView2 dataset, developed by the Defense Innovation Unit (DIU), is a benchmark dataset designed for assessing building damage using satellite imagery before and after natural disasters. It consists of high-resolution images covering diverse geographic regions, paired with detailed annotations of buildings and their corresponding damage levels. The dataset is structured to facilitate tasks such as semantic segmentation, change detection, and damage classification.

In this project, we focus on building segmentation, where the objective is to identify and delineate structures from satellite imagery accurately. By leveraging the pre-disaster imagery from XView2, my approach aims to construct precise building masks that serve as a foundation for downstream tasks like damage assessment.

The challenges inherent to this dataset, including: occlusions, diverse building architectures, and varying environmental conditions, provide a robust platform for developing and evaluating segmentation models.

The solution proposed utilizes a tailored U-Net architecture with a state-of-the-art backbone to enhance segmentation accuracy, also incorporating domain-specific data selection / augmentations and a combination of loss functions.

To optimize as much as possible the training time use:

  • Accelerator: TPU VM v3-8

  • Mixing Precision Policy mixed_bfloat16

Import Libraries

¶

In [1]:
import os
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from PIL import Image

import seaborn as sns

plt.style.use('ggplot')

Exploratory Data Analysis

¶

In [2]:
"""
CONFIG DATA
"""

include_post = False
group = 2
BACKBONE = 'SERESNEXT50'
In [3]:
base1 = "/kaggle/input/xview2-assess-building-damage/train_images_labels_targets/train_images_labels_targets/train/"
base2 = "/kaggle/input/xview2-assess-building-damage/tier3/tier3/"



def read_meta(base_path, name_group=1):
    l_images = os.listdir(base_path+"images")
    summarize = {}
    summarize["collection"], summarize["id"], summarize["state"] = list(zip(*[el.split('_')[:-1] for el in l_images]))

    df = pd.DataFrame(summarize)
    df["group"] = name_group

    df.sort_values(by=["collection","id","state"], inplace=True)
    df = df.groupby(['collection', 'id','group'])['state'].agg('_'.join).reset_index()

    return df



#df = read_meta(base1, name_group=1)
#df2 = read_meta(base2, name_group=2)
#df = pd.concat([df1, df2])

df = pd.read_csv("/kaggle/input/building-segmentation-model-for-satellite-imagery/building_info.csv", dtype='str')

for col in ['group','no-damage','minor-damage','major-damage','destroyed']:
    df[col] = df[col].astype('int')


if group==1:
    # just train group 1
    df = df[df['group']==1]

# check the building count per image
build_count = df[['no-damage','minor-damage','major-damage','destroyed']].sum(axis=1)
df['b_count'] = build_count
In [4]:
f,ax=plt.subplots(ncols=2, figsize=(7,5))

###################
ax[0].set_title("Images Count \nper collection")

temp_df = (df.groupby(["collection", df['b_count']!=0])['id']

           .count().unstack(1)

           .reset_index())

temp_df['total'] = temp_df[False]+temp_df[True]
temp_df.sort_values(by='total', ascending=True, inplace=True)

ax[0].barh(temp_df['collection'], temp_df[False], label='False', color='indigo')
ax[0].barh(temp_df['collection'], temp_df[True], left=temp_df[False], label='True', color='salmon')

ax[0].legend()
ax[0].set_ylabel("Collection"); ax[0].set_xlabel("Count")
###################

ax[1].set_title("Do the images contain buildings?")
ax[1].set_ylabel("Count")

(build_count!=0).value_counts().plot(kind='bar', color=['salmon','indigo'], ax=ax[1])

plt.tight_layout()
plt.show()

The Dataset consist of satellite images (dimension 1024x1024x3), from different geographical regions. A representative amount of the images has no buildings.

The distribution of images can interfere with segmentation performance. Ignoring Images with no buildings can potentially reduce model training time, however, it can lead to loss of context knowledge. A way to reduce images with no building is sampling images with representative regions (buildings, rives, lakes, etc) from each group of images (source).

In [112]:
from PIL import Image

def read_names(df):

    # read directory
    base = {1:"/kaggle/input/xview2-assess-building-damage/train_images_labels_targets/train_images_labels_targets/train/",
            2:"/kaggle/input/xview2-assess-building-damage/tier3/tier3/"}

    names = (df['collection']+"_" + df["id"])

    gen_base = base[df['group']]
    input_files = gen_base + "images/" + df['collection']+ "_" + df["id"] + "_pre_disaster.png"
    output_files = gen_base + "targets/" + df['collection']+ "_" + df["id"] + '_pre_disaster_target.png'
    
    return input_files, output_files


for i, name in enumerate(df['collection'].unique()):
    _ = df[df['collection']==name].sample(5*2)
    
    f,axs = plt.subplots(ncols=5, nrows=2, figsize=(10,4))

    for (row, ax) in zip(_.iterrows(),axs.ravel()):
        f_name, f_name_label = read_names(row[1])

        img = Image.open(f_name)
        ax.imshow(img)
        
        try:
            img_l = Image.open(f_name_label)
            ax.imshow(img_l, alpha=0.4, cmap="inferno")
        except:
            pass
        
        ax.axis("off")

    f.suptitle(name, y=0.95)

    plt.show()

    #if i==0: break

Ignoring Images with no buildings, the building count per image is the following:

In [6]:
# Ignore images with no-buildings

df_nb = df[~(df[['no-damage','minor-damage','major-damage','destroyed']].sum(axis=1)==0)]

f,ax=plt.subplots(figsize=(8, 4), ncols=2)
df_nb[["no-damage","minor-damage","major-damage","destroyed"]].sum(axis=1).hist(bins=np.arange(0, 200, 10), ax=ax[0],

                                                                             color='indigo', edgecolor='white')
ax[0].set_title("Building Count per Image")
ax[0].text(-0.3, 0.9, 'Bin Width = 10', transform=plt.gca().transAxes, fontsize=10, ha='right')
ax[0].set_xlabel("Building Group")
ax[0].set_ylabel("Count")

ax[1].set_title("Images with \n10 buildings or less")
(df_nb['b_count']<=10).value_counts().plot(kind='bar', color='indigo',ax=ax[1])

#plt.tight_layout()

plt.show()

A representative amount of images has 10 buildings or less, it seems to be a good idea to just ignore images without such low buildings.

However, this does not works so well, as expected, to ignore images with visible no-building structures (mountains, rivers, streets) can lead to misinterpretation at inference.)

In [7]:
from matplotlib.gridspec import GridSpec

plt.rcParams.update(plt.rcParamsDefault) # default settings for matplot

img = Image.open("/kaggle/input/xview2-assess-building-damage/tier3/tier3/images/joplin-tornado_00000001_pre_disaster.png")
crop_img = lambda stride, i, j: np.array(img)[0+stride*i:512+stride*i, 0+stride*j:512+stride*j]
gen_order = lambda a: [(i, j) for i in range(a) for j in range(a)]

# Create the figure

fig = plt.figure(figsize=(12, 4))

# Define the grid layout for the main row (3 sections)
outer_grid = GridSpec(1, 3, figure=fig, wspace=0.3)

# 1. Leftmost plot (1 imshow)

ax_left = fig.add_subplot(outer_grid[0])
ax_left.imshow(img, cmap='viridis')
ax_left.set_title("Original Image")
ax_left.axis("off")

middle_grid = outer_grid[1].subgridspec(2,2)
order = gen_order(2)

for i in range(4):
    ax = fig.add_subplot(middle_grid[i])
    ax.imshow(crop_img(512, *order[i]), cmap='magma')
    ax.axis("off")

fig.text(0.51, 0.9, "Patches with Stride = 512", ha="center", fontsize=12)

right_grid = outer_grid[2].subgridspec(3,3)
order = gen_order(3)
for i in range(9):
    ax = fig.add_subplot(right_grid[i])
    ax.imshow(crop_img(256, *order[i]), cmap='magma')
    ax.axis("off")

fig.text(0.8, 0.9, "Patches with Stride = 256", ha="center", fontsize=12)

plt.show()

High resolution images (1024x1024) can contain a large amount of information, reducing the image into patches (512,512) can improve the quality of the edges obtained from the segmentation model.

  • In our case, patches with stride 256 seems to produce good results when training.

An intial U-Net baseline model was created, further analysis shows poorly defined labels.

download (2).png

To reduce noise in model, those poorly labeled samples were identified and excluded. This was possible estimating building pixels difference between labels and predictions.

Data Preparation

¶

1 - Data Distribution¶

From the available Data:

  • Training Set: 90%
  • Validation Set: 10%
  • Test Set: An unkown set.

To estimate the model performance as realistic as possible, the Test Set is considered with satellite images of unknown geographical region and building distribution.

In [4]:
df_yb = df[~(df[['no-damage','minor-damage','major-damage','destroyed']].sum(axis=1)==0)]
df_nb = df[(df[['no-damage','minor-damage','major-damage','destroyed']].sum(axis=1)==0)]

limit_group = 500

df_n = pd.DataFrame()
for name in df_nb['collection'].unique(): #groupby('collection')['id'].count()
    temp_df = df[df['collection']==name]
    
    # downsample no-building images
    if len(temp_df)>limit_group:
        temp_df = temp_df.sample(n=limit_group, replace=False, random_state=2024)

    df_n = pd.concat([df_n, temp_df], axis=0, ignore_index=True)
In [5]:
from sklearn.model_selection import train_test_split

train, test = train_test_split(df_n, random_state=2024, test_size=0.1) # 0.25


meta_tr = train.groupby("collection")['id'].count().to_dict()
meta_ts = test.groupby("collection")['id'].count().to_dict()

######### Ignore poorly defined label samples

ignore_l = ["socal-fire_00001233","hurricane-harvey_00000107",
            "hurricane-harvey_00000138","socal-fire_00001313",
            "hurricane-harvey_00000245","moore-tornado_00000142",
            "lower-puna-volcano_00000089"]

train = train[~(train['collection']+"_"+train['id']).isin(ignore_l)]

#########

#for k in meta_tr.keys():
#    print(f"{k:<20} {meta_tr[k]:>5} {meta_ts.get(k):}")# {meta_tr[k]/meta_ts[k]:>3}")
In [6]:
def read_names(df, from_json=False, include_post=False):

    # read directory
    base1 = "/kaggle/input/xview2-assess-building-damage/train_images_labels_targets/train_images_labels_targets/train/"
    base2 = "/kaggle/input/xview2-assess-building-damage/tier3/tier3/"

    names = (df['collection']+"_" + df["id"])
    gen_base = df['group'].apply(lambda t: base1 if t==1 else base2)
    input_files = gen_base + "images/" + df['collection']+ "_" + df["id"] + "_pre_disaster.png"

    if from_json:
        output_files = gen_base + "labels/" + df['collection']+ "_" + df["id"] + '_pre_disaster.json'
    else:
        output_files = gen_base + "targets/" + df['collection']+ "_" + df["id"] + '_pre_disaster_target.png'

    if include_post:
        """
        include '_post_disaster' images without damage
        """

        temp = df[df[['minor-damage','major-damage','destroyed']].astype('int').sum(axis=1)==0].copy()
        gen_base2 = temp['group'].apply(lambda t: base1 if t==1 else base2)
        input_files2 = gen_base2 + "images/" + temp['collection']+ "_" + temp["id"] + "_post_disaster.png"

        if from_json:
            output_files2 = gen_base2 + "labels/" + temp['collection']+ "_" + temp["id"] + '_post_disaster.json'
        else:
            output_files2 = gen_base2 + "targets/" + temp['collection']+ "_" + temp["id"] + '_post_disaster_target.png'

        input_files = pd.concat([input_files,input_files2], ignore_index=True)
        output_files = pd.concat([output_files,output_files2], ignore_index=True)

    return input_files.values, output_files.values

train_names = read_names(train, from_json=True, include_post=include_post)
test_names  = read_names(test, from_json=True, include_post=include_post)

print("Train Files:      ", len(train_names[0]))
print("Validation Files: ", len(test_names[0]))
Train Files:       5064
Validation Files:  564

2 - Load Dataset¶

In [ ]:
import tensorflow as tf
from tensorflow.keras import layers

tpu = None

try:
    tpu = tf.distribute.cluster_resolver.TPUClusterResolver()
    tf.config.experimental_connect_to_cluster(tpu)
    tf.tpu.experimental.initialize_tpu_system(tpu)
    #`tf.distribute.experimental.TPUStrategy` is deprecated
    strategy = tf.distribute.TPUStrategy(tpu)
except:
    strategy = tf.distribute.get_strategy()
In [8]:
from tensorflow.keras import mixed_precision

# NVIDIA GPUs support using a mix of float16 and float32,
# while TPUs and Intel CPUs support a mix of bfloat16 and float32.

if 'tpu' in strategy.cluster_resolver.__str__():
    print('Using TPU mixed precision')
    policy = mixed_precision.Policy('mixed_bfloat16')
else:
    policy = mixed_precision.Policy('mixed_float16')

mixed_precision.set_global_policy(policy)
Using TPU mixed precision
In [ ]:
!pip install shapely
!!pip install -U segmentation-models # automatic U-Net implementation for tf.keras
#!pip install -U image-classifiers # collection of novel classification models (2019), already installed with segmentation-models
In [10]:
from PIL import Image, ImageDraw
from shapely import wkt
import numpy as np
import json


# create binary mask from label json file
def create_build_from_json(file_path):

    data = tf.io.read_file(file_path)
    data = json.loads(data.numpy().decode('utf-8'))

    img = Image.new('L', (1024, 1024), color=0) #'black')
    draw = ImageDraw.Draw(img, 'L')
    #color = (255) #, 255, 255, 125) # white

    for cnt, polygon in enumerate( data['features']['xy'] ):
        x,y = wkt.loads(polygon['wkt']).exterior.coords.xy
        coords = list(zip(x,y))
        draw.polygon(coords, fill=1)

    return np.array(img)[...,np.newaxis] # dtype uint8
In [11]:
import tensorflow  as tf
from tensorflow.keras import backend
from scipy.ndimage import distance_transform_edt as distance


print(tf.__version__)

def read_image_tf(path):
    # Read the contents of the image file as a string
    temp = tf.io.read_file(path)
    # Decode the string into a tensor
    return tf.io.decode_image(temp)


# preprocess function to manage file reading

def preprocess_load_file(use_json=False):
    def funct_inter(img_path, target_path):

        image = read_image_tf(img_path)

        if use_json:
            label = tf.py_function(func=create_build_from_json, inp=[target_path], Tout=tf.uint8)
        else:
            label = read_image_tf(target_path)

        # Normalize Image
        image = tf.cast(image, tf.float32)
        label = tf.cast(label, tf.float32)

        # Scale the pixel values to the range [0, 1]
        image = image / 255.0

        # set shape information explicitly
        image.set_shape([1024,1024,3])
        label.set_shape([1024,1024,1])

        return image, label

    return funct_inter
2.16.1
In [12]:
def crop_image(input_size, crop_size, stride):
    """
        Crop images from 1024x1024 to 512x512 patches from image
        inputs shape: (rows. columns. channels) -> output (#splits, rows. columns. channels)
    """

    def inner_funct(image, label):
        patches = []
        for i in range(0, input_size, stride):
            for j in range(0, input_size, stride):

                # patches inside image only
                if (i+crop_size)>input_size or (j+crop_size)>input_size:
                    continue

                patch_im = tf.image.crop_to_bounding_box(image, i, j, crop_size, crop_size)
                patch_lb = tf.image.crop_to_bounding_box(label, i, j, crop_size, crop_size)

                patches.append( (patch_im, patch_lb) )

        return tf.data.experimental.from_list(patches)
    return inner_funct
In [13]:
BATCH_SIZE = 9  #8 #GPU #1*9 #TPU
GLOBAL_BATCH_SIZE = BATCH_SIZE*strategy.num_replicas_in_sync

print(GLOBAL_BATCH_SIZE) # 256 in TPU
72
In [14]:
from tensorflow.keras.layers import Layer


class BinarizeMaskLayer(Layer):

    def __init__(self, threshold=0.5, **kwargs):
        super(BinarizeMaskLayer, self).__init__(**kwargs)
        self.threshold = threshold

    def call(self, inputs):
        # Apply the binarization threshold across the batch
        return tf.where(inputs > self.threshold, 1.0, 0.0)

    def get_config(self):
        config = super().get_config()
        config.update({"threshold": self.threshold})
        return config

# simple random flip applied to whole batch
class Augment(Layer):
    def __init__(self, seed=42):
        super().__init__()
        # both use the same seed, so they'll make the same random changes.
        # **MUST TWO** times cause, each call can generate differente effects

        self.aug_inputs = tf.keras.Sequential([
            tf.keras.layers.RandomRotation(factor=0.2, fill_mode="constant", seed=seed),
            tf.keras.layers.RandomFlip(mode='horizontal_and_vertical', seed=seed),
            tf.keras.layers.RandomTranslation(height_factor=0.1, width_factor=0.1, fill_mode="constant", seed=seed),
            tf.keras.layers.RandomZoom(height_factor=0.2, width_factor=0.2, fill_mode='constant', seed=seed),
            tf.keras.layers.RandomBrightness(factor=0.1, value_range=(0.0, 1.0), seed=seed),
            tf.keras.layers.RandomContrast(factor=0.1, seed=seed)

        ])

        self.aug_labels = tf.keras.Sequential([
            tf.keras.layers.RandomRotation(factor=0.2, fill_mode="constant", seed=seed),
            tf.keras.layers.RandomFlip(mode='horizontal_and_vertical', seed=seed),
            tf.keras.layers.RandomTranslation(height_factor=0.1, width_factor=0.1, fill_mode="constant", seed=seed),
            tf.keras.layers.RandomZoom(height_factor=0.2, width_factor=0.2, fill_mode='constant', seed=seed),
            BinarizeMaskLayer(threshold=0.5) # to avoid loss precision in border resolution
        ])



    def call(self, inputs, labels):
        inputs = self.aug_inputs(inputs)
        labels = self.aug_labels(labels)

        return inputs, labels
In [15]:
def has_nonzero_label(image, label):

    # Check if there are any non-zero elements in the label
    limit = int(512*512 * 0.025) # at leat 2.5% of building pixels per image
    mask_sum = tf.reduce_sum(tf.cast(tf.not_equal(label, 0), dtype="int32"))

    return mask_sum >= limit
    #return tf.reduce_any(tf.not_equal(label, 0))
In [16]:
"""
CONFIG
"""
input_size=1024
crop_size = 512

crop_function_train = crop_image(input_size, crop_size, stride=512//2)
crop_function_test  = crop_image(input_size, crop_size, stride=512)

"""
Dataset Creation
"""

ds_train = tf.data.Dataset.from_tensor_slices((train_names[0],train_names[1]))
ds_train = (ds_train.map(preprocess_load_file(use_json=True),
                         num_parallel_calls=tf.data.AUTOTUNE)
            # Map the crop function to each image, flat_map is used to flatten the list of patches
            .flat_map(lambda image, label: (crop_function_train(image, label)))
            #.filter(has_nonzero_label)
            .cache()
            .shuffle(buffer_size = 8000*2) #300) #8000) #len(train_names[0])*9) #15000) #len(train_names[0])*9 = 18000
            .batch(GLOBAL_BATCH_SIZE)
            #.map(Augment(2024), num_parallel_calls=tf.data.AUTOTUNE)
            .prefetch(tf.data.AUTOTUNE)
           )



ds_test = tf.data.Dataset.from_tensor_slices((test_names[0],test_names[1]))
ds_test = (ds_test.map(preprocess_load_file(use_json=True),
                       num_parallel_calls=tf.data.AUTOTUNE)
           .flat_map(lambda image, label: (crop_function_test(image, label)))
           #.filter(has_nonzero_label)
           .cache()
           .batch(GLOBAL_BATCH_SIZE)
           .prefetch(tf.data.AUTOTUNE))

t_image, t_label = ds_test.take(1).as_numpy_iterator().next()
t_image.shape, t_label.shape
Out[16]:
((72, 512, 512, 3), (72, 512, 512, 1))

Model Training

¶

U-Net is an effective way to evaluate image segmentation, the trick part is to adapt is architecture to be effective, in this case:

BACKBONE: SE-ResNeXt50, pretrained on Imagenet

  • SE blocks add an "attention" mechanism that helps the network focus on relevant and fine-grained

Asymmetric Convolution Block (ACB) at Decoder

  • A combination of asymmetric kernels is used to improve the efficiency and performance of convolutional neural networks.

Loss Function

  • dice + 10.0 * binary_focal_loss

Focal Loss helps to focus training on unbalanced classes, Dice loss is prefered for segmentation tasks.

In [17]:
import os

os.environ["SM_FRAMEWORK"] = "tf.keras"

import segmentation_models as sm # ignore in TPU env
from classification_models.tfkeras import Classifiers
Segmentation Models: using `tf.keras` framework.
In [18]:
from tensorflow.keras.layers import Conv2D, Reshape, Multiply, Permute, Activation, Dot, Add, MaxPooling2D, UpSampling2D
from tensorflow.keras import layers


class AttentionGate(tf.keras.layers.Layer):
    def __init__(self, filters, name):
        super(AttentionGate, self).__init__(name=f"AttentionGate_{name}")

        self.sigmoid = Activation('sigmoid')
        self.relu = layers.ReLU()

        # Define the convolutions
        self.conv_o = Conv2D(filters, kernel_size=1) # representation of the current pixel.
        self.conv_s = Conv2D(filters, kernel_size=1) # Representation of the pixels you’re attending to.
        self.psi    = Conv2D(1, kernel_size=1)

    def call(self, inputs, skip):

        out_a  = self.conv_o(inputs) # [batch_size, height, width, filters // 2]
        skip_a = self.conv_s(skip)   # [batch_size, height, width, filters // 2]
        psi_a  = self.psi(self.relu(out_a + skip_a)) # [batch_size, height, width, 1]
        attention = self.sigmoid(psi_a) # [batch_size, height, width, 1]
        skip = skip * attention

        return skip
In [19]:
from tensorflow.keras.layers import (Conv2D, Conv2DTranspose, Concatenate,
                                     UpSampling2D, Add, Activation, BatchNormalization)


def asymmetric_conv_block(input_tensor, filters, n_sx=''):
    # 1x3 convolution
    conv1 = Conv2D(filters, (1, 3), padding='same', name=n_sx+'_cv1')(input_tensor)
    # 3x1 convolution
    conv2 = Conv2D(filters, (3, 1), padding='same', name=n_sx+'_cv2')(input_tensor)
    # 3x3 convolution
    conv3 = Conv2D(filters, (3, 3), padding='same', name=n_sx+'_cv3')(input_tensor)

    # Combine the outputs of the asymmetric convolutions
    x = Add()([conv1, conv2, conv3])
    x = BatchNormalization(name=n_sx+'_bn')(x)
    x = Activation('relu')(x)

    return x


def decode_block(filters, x, concat_l, n_sx='', include_post_conv=True, include_attention_gate=False):
    x = Conv2DTranspose(filters, (3, 3), strides=(2, 2), padding='same', activation='swish', name=n_sx+'_conv_trans')(x)

    if len(concat_l)!=0:
        if include_attention_gate:
            skip_out = AttentionGate(filters=filters, name=n_sx)(x, concat_l[0])
            x = Concatenate()([x, skip_out])
        else:
            x = Concatenate()([x]+concat_l) #[el for el in concat_l])

    if include_post_conv:
        x = asymmetric_conv_block(x, filters=filters, n_sx=n_sx+'_acb')
    return x


def generate_model(enc_model, enc_layers):

    if enc_layers[0]==-1:
        enc5, enc4, enc3, enc2, enc1 = [enc_model.layers[name].output for name in enc_layers ]
    else:
        enc5, enc4, enc3, enc2, enc1 = [enc_model.get_layer(name=name).output for name in enc_layers]

    dec1 = decode_block(filters=512, x=enc5, concat_l=[enc4], n_sx='dec1', include_attention_gate=False)
    #print(dec1.shape)

    dec2 = decode_block(filters=256, x=dec1, concat_l=[enc3], n_sx='dec2', include_attention_gate=False)
    #print(dec2.shape)

    dec3 = decode_block(filters=128, x=dec2, concat_l=[enc2], n_sx='dec3', include_attention_gate=False)
    #print(dec3.shape)

    ## extra conections
    ## kernel should be twice the size of strides to be similar to "bilinear"
    ##extra1 = Conv2DTranspose(filters=64, kernel_size=(16, 16), strides=(8, 8), padding='same')(dec1)
    ##extra2 = Conv2DTranspose(filters=64, kernel_size=(8, 8), strides=(4, 4), padding='same')(dec2)

    #extra1 = UpSampling2D(size=(8,8), interpolation="bilinear")(dec1)
    #extra1 = Conv2D(64, kernel_size=(3,3), padding='same',activation="relu", name='ext1_cv')(extra1)

    #extra2 = UpSampling2D(size=(4,4), interpolation="bilinear")(dec2)
    #extra2 = Conv2D(64, kernel_size=(3,3), padding='same',activation="relu", name='ext2_cv')(extra2)

    dec4 = decode_block(filters=64, x=dec3, concat_l=[enc1], #extra1, extra2],
                        n_sx='dec4', include_attention_gate=False)

    # Output layer
    x = Conv2DTranspose(16, (3, 3), strides=(2, 2), padding='same')(dec4)
    #print(x.shape)
    outputs = Conv2D(1, (1, 1), activation='sigmoid', dtype="float32")(x)
    #print(outputs.shape)
    # Create model

    return tf.keras.Model(inputs=enc_model.input, outputs=outputs)

Metrics Selected:

  • IOU score
  • F0.5 score
In [20]:
# Obtain alpha

"""
total = 0
positive_c = 0
for i, (t1_image, (t1_label, _)) in tqdm(enumerate(ds_train)):
    # to use float16 labels
    positive_c += int(tf.reduce_sum(tf.cast(t1_label, dtype="float32")).numpy())
    total      += tf.size(t1_label).numpy()

alpha = np.round( (total - positive_c)/total, 2)
print("alpha ", alpha)

"""

#alpha = 0.88 # train +tier3 # no empty slices

alpha = 0.9
In [21]:
from segmentation_models.base import Metric
import segmentation_models.base.functional as F



SMOOTH = 1e-5
class IOUScore(Metric):
    r""" The `Jaccard index`_, also known as Intersection over Union and the Jaccard similarity coefficient
    (originally coined coefficient de communauté by Paul Jaccard), is a statistic used for comparing the
    similarity and diversity of sample sets. The Jaccard coefficient measures similarity between finite sample sets,
    and is defined as the size of the intersection divided by the size of the union of the sample sets:

    .. math:: J(A, B) = \frac{A \cap B}{A \cup B}

    """

    def __init__(
            self,class_weights=None,class_indexes=None,threshold=0.5,
            per_image=False,smooth=SMOOTH,name=None,):

        name = name or 'iou_score'
        super().__init__(name=name)
        self.class_weights = class_weights if class_weights is not None else 1
        self.class_indexes = class_indexes
        self.threshold = threshold
        self.per_image = per_image
        self.smooth = smooth

    def __call__(self, gt, pr):
        # Explicitly cast inputs to float32 for consistency
        gt = tf.cast(gt, tf.float32)
        pr = tf.cast(pr, tf.float32)

        return F.iou_score(gt,pr,
            class_weights=self.class_weights,class_indexes=self.class_indexes,
            smooth=self.smooth,
            per_image=self.per_image, threshold=self.threshold, **self.submodules
        )



class FScore(Metric):
    r"""The F-score (Dice coefficient) can be interpreted as a weighted average of the precision and recall,
    where an F-score reaches its best value at 1 and worst score at 0.
    The relative contribution of ``precision`` and ``recall`` to the F1-score are equal.
    The formula for the F score is:

    .. math:: F_\beta(precision, recall) = (1 + \beta^2) \frac{precision \cdot recall}
        {\beta^2 \cdot precision + recall}
    """

    def __init__(self, beta=1, class_weights=None, class_indexes=None,
                 threshold=None, per_image=False, smooth=SMOOTH,name=None,):
        name = name or 'f{}-score'.format(beta)
        super().__init__(name=name)
        
        self.beta = beta
        self.class_weights = class_weights if class_weights is not None else 1
        self.class_indexes = class_indexes
        self.threshold = threshold
        self.per_image = per_image
        self.smooth = smooth

    def __call__(self, gt, pr):
        # Explicitly cast inputs to float32 for consistency
        gt = tf.cast(gt, tf.float32)
        pr = tf.cast(pr, tf.float32)

        return F.f_score(gt,pr,
            beta=self.beta,
            class_weights=self.class_weights,
            class_indexes=self.class_indexes,
            smooth=self.smooth,
            per_image=self.per_image,
            threshold=self.threshold,
            **self.submodules
        )
In [22]:
class CombLoss:
    def __init__(self, from_logits=False):
        self.from_logits = from_logits
        self.dice_loss = tf.keras.losses.Dice()
        self.binary_focal_loss = tf.keras.losses.BinaryFocalCrossentropy()

    def __call__(self, y_true, y_pred):
        loss = self.dice_loss(y_true, y_pred) + 10.0 * self.binary_focal_loss(y_true, y_pred)
        return loss
In [23]:
with strategy.scope():

    enc_layers =  (-1, 1078, 584, 254, 4)
    classifier, preprocess_input = Classifiers.get('seresnext50') # 'seresnext50': (1078, 584, 254, 4)
    encoder_model = classifier(input_shape=(512,512,3), weights=None,# 'imagenet',
                               include_top=False)
    #encoder_model.trainable=True

    model = generate_model(enc_model=encoder_model, enc_layers=enc_layers)
    model.load_weights("/kaggle/input/building-segmentation-model-for-satellite-imagery/bseg_unet_SERESNEXT50_v38_tpu.weights.h5")

    model.compile(
        tf.keras.optimizers.AdamW(learning_rate=0.000025),#15), #'Adam', #keras.optimizers.SGD(learning_rate=1e-5, momentum=0.9),
        loss=CombLoss,# surface_loss_keras(factor), #binary_focal_dice_loss, #sm.losses.bce_jaccard_loss,
        metrics=[IOUScore(threshold=0.5),    #tf.keras.metrics.BinaryIoU(target_class_ids=[0, 1], threshold=0.5),
                 FScore(beta=0.5)],
    )
I0000 00:00:1732859035.673834      13 device_compiler.h:188] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.

With this, the size of the model is:

  • Total params: 52,187,281 (199.08 MB),
  • Trainable params: 52,117,137 (198.81 MB)
  • Non-trainable params: 70,144 (274.00 KB)
In [92]:
from IPython.display import clear_output

selected_slice = 27

def display(display_list):

    plt.figure(figsize=(15, 15))
    title = ['Input Image', 'True Mask', 'Predicted Mask']

    for i in range(len(display_list)):
        plt.subplot(1, len(display_list), i+1)
        plt.title(title[i])
        
        if i>=1:
            plt.imshow(display_list[i], vmin=0, vmax=1, cmap="gray")
        else:
            plt.imshow(display_list[i])
        plt.axis('off')

    plt.show()


def show_predictions():
    display([t_image[selected_slice],
             t_label[selected_slice],
             model.predict(t_image)[selected_slice].astype('float')])


class DisplayCallback(tf.keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs=None):
        clear_output(wait=True)
        show_predictions()
        print('\nSample Prediction after epoch {}\n'.format(epoch+1))
In [ ]:
#from transformers.modeling_tf_utils import keras

early_stopping = tf.keras.callbacks.EarlyStopping(monitor='val_loss', #'val_iou_score',
                                                  patience=4, mode='min', verbose=1,
                                                  min_delta=0, restore_best_weights=True)

# To reduce Python overhead and maximize the performance of your TPU, Produce erros with generator dataset
# pass in the steps_per_execution argument to Keras Model.compile.
#steps_per_epoch  = len(train_names[0]) // (GLOBAL_BATCH_SIZE*4)
#validation_steps = len(test_names[0]) // (GLOBAL_BATCH_SIZE*4)

history = model.fit(x=ds_train, epochs=128,
                    #steps_per_epoch=steps_per_epoch,
                    validation_data=ds_test, #validation_steps=validation_steps,
                    callbacks=[DisplayCallback(),
                               early_stopping])

from IPython.display import FileLink
# Save the entire model to a HDF5 file.

with strategy.scope():
    model.save_weights(f'bseg_unet_{BACKBONE}_v38_tpu.weights.h5')

FileLink(f'bseg_unet_{BACKBONE}_v38_tpu.weights.h5')
In [93]:
show_predictions()
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 238ms/step

These are other examples:

In [102]:
t_pred = model.predict(t_image) #.logits
#t_pred = tf.math.argmax(t_pred, axis=1) # create mask

for i in range(5,GLOBAL_BATCH_SIZE,GLOBAL_BATCH_SIZE//4):
    f, ax =plt.subplots(ncols=3, figsize=(10,5))

    ax[0].imshow(t_image[i])
    ax[1].imshow(t_label[i], alpha=1.0, vmin=0, vmax=1, cmap="gray")
    ax[2].imshow(t_pred[i]>0.5, alpha=1.0, vmin=0, vmax=1, cmap="gray")

    for _ in range(3):
        ax[_].axis("off")
        
    plt.show()
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 233ms/step

Post Processing Model¶

In [34]:
r1 = tf.image.rot90(t_image, k=1)
r2 = tf.image.rot90(t_image, k=2)
r3 = tf.image.rot90(t_image, k=3)

pred_l = [model.predict(el) for el in [t_image, r1,r2,r3]]
r1_inv = tf.image.rot90(pred_l[1], k=-1)
r2_inv = tf.image.rot90(pred_l[2], k=-2)
r3_inv = tf.image.rot90(pred_l[3], k=-3)
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 247ms/step
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 253ms/step
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 250ms/step
3/3 ━━━━━━━━━━━━━━━━━━━━ 1s 259ms/step
In [115]:
f,ax = plt.subplots(figsize=(10,4), ncols=5)

ax[0].imshow(t_label[selected_slice], cmap="gray"); ax[0].set_title("Original")
ax[1].imshow(pred_l[0][selected_slice], cmap="gray"); ax[1].set_title("Pred")
ax[2].imshow(r1_inv[selected_slice], cmap="gray"); ax[2].set_title("Pred rot90")
ax[3].imshow(r2_inv[selected_slice], cmap="gray"); ax[3].set_title("Pred rot180")
ax[4].imshow(r3_inv[selected_slice], cmap="gray"); ax[4].set_title("Pred rot270")

for _ in ax.ravel():
    _.axis("off")

plt.show()

Despite the fact that convolutions allow to reduce the features position/angle prediction dependance, predictions from rotated inputs tends to differ. But if we create a rotated predictions, the mean average of them can be more robust without retraining model.

In [114]:
mean_pred = (pred_l[0] + r1_inv + r2_inv + r3_inv)/4

f,ax=plt.subplots(ncols=3)

i= selected_slice
ax[0].imshow(t_label[i], cmap="gray")
ax[1].imshow(pred_l[0][i], cmap="gray")
ax[2].imshow(mean_pred[i], cmap="gray")

ax[0].set_title("Label")
ax[1].set_title("Prediction")
ax[2].set_title("Processed prediction")

for _ in ax.ravel():
    _.axis("off")
    
plt.show()
In [ ]:
from tensorflow.keras.layers import Average


class Rot90Layer(tf.keras.layers.Layer):

    """
    A custom layer to rotate images by 90 degrees.

    Args:
        rotation (int): Number of 90-degree rotations. Positive values rotate counterclockwise.
    """

    def call(self, inputs, rotations=1):
        return tf.image.rot90(inputs, k=rotations)


def post_model(model):

    image = tf.keras.Input(shape=(512,512,3))
    rot_images = [Rot90Layer()(image, rotations=i) for i in range(1, 4)]
    pred_l = [model(image)] + [model(rot_image) for rot_image in rot_images]
    pred_l = [Rot90Layer()(pred, rotations=-1*i) for i, pred in enumerate(pred_l)]
    output = Average(dtype="float32")(pred_l)

    return tf.keras.Model(inputs=image, outputs=output)


with strategy.scope():
    ### base model
    enc_layers =  (-1, 1078, 584, 254, 4)
    classifier, preprocess_input = Classifiers.get('seresnext50') # 'seresnext50': (1078, 584, 254, 4)
    encoder_model = classifier(input_shape=(512,512,3), weights=None,include_top=False)
    encoder_model.trainable=True

    model_base = generate_model(enc_model=encoder_model, enc_layers=enc_layers)
    model_base.load_weights("/kaggle/input/building-segmentation-model-for-satellite-imagery/bseg_unet_SERESNEXT50_v38_tpu.weights.h5")
    
    #model_base = model
    
    ########## post model
    post_m = post_model(model_base)
    post_m.trainable=False
    
    post_m.compile(
        tf.keras.optimizers.AdamW(learning_rate=0.00015),
        loss= lambda y_true, y_pred: 0.0, # Dummy Loss
        metrics=[IOUScore(threshold=0.4), # Low considering that noise was reduced as much as possible
                 FScore(beta=0.5)],
    )

These are some examples:

In [26]:
t_pred = post_m.predict(t_image) #.logits

#t_pred = tf.math.argmax(t_pred, axis=1) # create mask

for i in range(5,GLOBAL_BATCH_SIZE,GLOBAL_BATCH_SIZE//4):
    f, ax =plt.subplots(ncols=3, figsize=(10,5))

    ax[0].imshow(t_image[i])
    ax[1].imshow(t_label[i], alpha=1.0, vmin=0, vmax=1, cmap="gray")
    ax[2].imshow(t_pred[i]>0.5, alpha=1.0, vmin=0, vmax=1, cmap="gray")

    ax[0].set_title(i)

    for _ in range(3):
        ax[_].axis("off")
            
    plt.show()
3/3 ━━━━━━━━━━━━━━━━━━━━ 2s 596ms/step

Comparison over test set¶

In [117]:
results = model.evaluate(ds_test)
print("Val Loss: ",results[0], "IOU Score: ", results[1], "f0.5-score: ", results[2])
32/32 ━━━━━━━━━━━━━━━━━━━━ 15s 436ms/step - f0.5-score: 0.7331 - iou_score: 0.7160 - loss: 0.3306
Val Loss:  0.3356271982192993 IOU Score:  0.737855076789856 f0.5-score:  0.7313465476036072
In [118]:
results = post_m.evaluate(ds_test)
print("Val Loss: ",results[0], "IOU Score: ", results[1], "f0.5-score: ", results[2])
32/32 ━━━━━━━━━━━━━━━━━━━━ 45s 1s/step - f0.5-score: 0.6805 - iou_score: 0.6856 - loss: 0.0000e+00
Val Loss:  0.0 IOU Score:  0.697926938533783 f0.5-score:  0.6762031316757202

Validation Set Metrics

Model IOU Score: 0.737 f0.5-score: 0.731
Model with Post-processing IOU Score: 0.697 f0.5-score: 0.676

Quality vs Quantity

Potential Trade-Off: Post-processing might be sacrificing some of the segmentation precision (in terms of strict overlap with ground truth) for visual improvements (less noise and smoother results).

If reducing artifacts is crucial (e.g., for tasks requiring smooth boundaries), then improving visual quality via post-processing might be a worthwhile trade-off, even if it results in a slight drop in the metric.

Test set performance

¶

In [29]:
base2 = "/kaggle/input/xview2-assess-building-damage/test_images_labels_targets/test_images_labels_targets/test/"
images_test = tf.io.gfile.listdir(base2+"images")
images_test = [el.split('_pre')[0] for el in images_test if len(el.split('_pre'))==2]

############ ignore poorly defined labels
m_l = ['socal-fire_00001378', 'hurricane-harvey_00000133', 'socal-fire_00001029']
images_test = [el for el in images_test if el not in m_l]
############

images  = [base2+'images/' + el + '_pre_disaster.png' for el in images_test]
targets = [base2+'targets/'+ el + '_pre_disaster_target.png'for el in images_test]


ds_test2 = tf.data.Dataset.from_tensor_slices((images, targets))
ds_test2 = (ds_test2.map(preprocess_load_file(use_json=False),
                         num_parallel_calls=tf.data.AUTOTUNE)
            .flat_map(lambda image, label: (crop_function_test(image, label)))
            .batch(GLOBAL_BATCH_SIZE)
            .prefetch(tf.data.AUTOTUNE))
In [33]:
results = model.evaluate(ds_test2)
print("Val Loss: ",results[0], "IOU Score: ", results[1], "f0.5-score: ", results[2])
52/52 ━━━━━━━━━━━━━━━━━━━━ 27s 499ms/step - f0.5-score: 0.7924 - iou_score: 0.7594 - loss: 0.3568
Val Loss:  0.370125949382782 IOU Score:  0.7451341152191162 f0.5-score:  0.7824866771697998
In [34]:
results = post_m.evaluate(ds_test2)
print("Val Loss: ",results[0], "IOU Score: ", results[1], "f0.5-score: ", results[2])
52/52 ━━━━━━━━━━━━━━━━━━━━ 75s 1s/step - f0.5-score: 0.7382 - iou_score: 0.7169 - loss: 0.0000e+00
Val Loss:  0.0 IOU Score:  0.7113664150238037 f0.5-score:  0.7221629023551941

Test Set Metrics

Model IOU Score: 0.745 f0.5-score: 0.782
Model with Post-processing IOU Score: 0.711 f0.5-score: 0.722

Plot Results¶

In [ ]:
ds_test2 = tf.data.Dataset.from_tensor_slices((images, targets))
ds_test2 = (ds_test2.map(preprocess_load_file(flag=False, use_json=False), #cont=[mean, std]),
                         num_parallel_calls=tf.data.AUTOTUNE)
            #.flat_map(lambda image, label: (crop_function_test(image, label)))
            .batch(GLOBAL_BATCH_SIZE)
            .prefetch(tf.data.AUTOTUNE))

t2_image, t2_label = ds_test2.take(1).as_numpy_iterator().next()
t2_pred = model.predict(t2_image)
In [77]:
for i in [6,8,10,11, 14]:
    f,ax=plt.subplots(ncols=3, figsize=(10,4))

    ax[0].imshow(t2_image[i])
    ax[1].imshow(t2_label[i], cmap="gray")
    ax[2].imshow(t2_pred[i], cmap="gray")
    
    ax[0].set_title("Image")
    ax[1].set_title("Label")
    ax[2].set_title("Prediction")

    for _ in range(3):
        ax[_].axis("off")
        
    plt.show() #

Final Analysis¶

  • During the development of this project a selection of images was established. The XView2 dataset contains a very small number of misalignments, artifacts, or poorly defined labels, which have already been observed in other work. This can be further improved with more diverse satellite images from other sources.

  • The implemented U-Net model can be refined through further research. Specifically, backbones can play a critical role, however, it was noted that Segformer does not appear to outperform seresnext50.

  • Dice Loss along with Focal Loss played a fundamental role during model training.